Micron Document
🎖️GitЯра🎖️

Commit bc5cf83df7fd6b405ffc521d7c3ed4e1f3e8271a


Parents : 19c61fc
Author : Jeremiah K <17190268+jeremiah-k@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-23T10:21:07-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-23T15:21:07Z

fix(messaging): reduce composer recomposition and typing jank (#6318)

Changes
Diff

diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts
index 7b6b3b3939..4a5c66230b 100644
--- a/feature/messaging/build.gradle.kts
+++ b/feature/messaging/build.gradle.kts
@@ -34,6 +34,7 @@ kotlin {
implementation(libs.androidx.paging.common)
implementation(libs.androidx.paging.compose)
+ implementation(libs.kotlinx.collections.immutable)
// JetBrains Material 3 Adaptive (multiplatform ListDetailPaneScaffold)
implementation(libs.jetbrains.compose.material3.adaptive)

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
index 7ee3e51def..1fcf32d27b 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
@@ -82,6 +82,9 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
@@ -99,7 +102,6 @@ import org.meshtastic.core.resources.type_a_message
import org.meshtastic.core.resources.unknown_channel
import org.meshtastic.core.ui.component.InlineStyle
import org.meshtastic.core.ui.component.SharedContactDialog
-import org.meshtastic.core.ui.component.inlineMarkdownStyleRanges
import org.meshtastic.core.ui.component.smartScrollToIndex
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Send
@@ -150,6 +152,13 @@ fun MessageScreen(
val focusManager = LocalFocusManager.current
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
+ val mentionCandidates by remember {
+ derivedStateOf {
+ nodes
+ .associate { it.user.id to MentionCandidate(it.user.id, it.user.long_name, it.user.short_name) }
+ .toPersistentMap()
+ }
+ }
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsStateWithLifecycle()
@@ -435,7 +444,7 @@ fun MessageScreen(
isEnabled = connectionState is ConnectionState.Connected,
isHomoglyphEncodingEnabled = homoglyphEncodingEnabled,
textFieldState = messageInputState,
- nodes = nodes,
+ mentionCandidates = mentionCandidates,
onSendMessage = {
val messageText = messageInputState.text.toString().trim { it.isWhitespace() }
if (messageText.isNotEmpty()) {
@@ -531,12 +540,132 @@ internal fun currentMentionQuery(text: String, selection: TextRange): MentionQue
return MentionQuery(at, cursor, query)
}
-private fun Node.matchesMention(query: String): Boolean {
- if (query.isEmpty()) return true
- val q = query.lowercase()
- return user.long_name.lowercase().contains(q) ||
- user.short_name.lowercase().contains(q) ||
- user.id.lowercase().contains(q)
+/**
+ * Lightweight mention target — contains only fields that affect composer presentation. A telemetry-only [Node] change
+ * leaves a [MentionCandidate] unchanged so the composable's recomposition scope stays narrow. Normalized fields are
+ * cached once so matching does not allocate three lowercase strings per candidate on every keystroke.
+ */
+internal data class MentionCandidate(val id: String, val longName: String, val shortName: String) {
+ private val normalizedId = id.lowercase()
+ private val normalizedLongName = longName.lowercase()
+ private val normalizedShortName = shortName.lowercase()
+
+ fun matchesNormalizedQuery(query: String): Boolean =
+ normalizedLongName.contains(query) || normalizedShortName.contains(query) || normalizedId.contains(query)
+}
+
+internal fun matchingMentionCandidates(
+ candidates: Collection<MentionCandidate>,
+ query: String,
+ limit: Int = MENTION_SUGGESTION_LIMIT,
+): List<MentionCandidate> = when {
+ limit <= 0 -> emptyList()
+
+ query.isEmpty() -> candidates.take(limit)
+
+ else -> {
+ val normalizedQuery = query.lowercase()
+ candidates.asSequence().filter { it.matchesNormalizedQuery(normalizedQuery) }.take(limit).toList()
+ }
+}
+
+/** Regex-backed inline-markdown style ranges for the live TextField path — avoids the IntelliJ GFM parser. */
+private val LIVE_BOLD = Regex("\\*\\*([^*]+)\\*\\*")
+private val LIVE_ITALIC = Regex("(?<!\\*)\\*([^*]+)\\*(?!\\*)") // single *, not part of **
+private val LIVE_STRIKE = Regex("~~([^~]+)~~")
+private val LIVE_CODE = Regex("`([^`]+)`")
+
+internal data class LiveStyleSpan(val range: IntRange, val style: InlineStyle)
+
+/**
+ * Detects live-markdown spans in [source] using simple regex patterns instead of a full GFM AST parser.
+ *
+ * The live composer intentionally previews only asterisk emphasis, strikethrough, and inline code. Underscore emphasis
+ * remains raw while editing, but the sent message still uses the full Markdown renderer.
+ */
+internal fun liveInlineMarkdownStyleRanges(source: String): List<LiveStyleSpan> {
+ if (!source.any { it in LIVE_STYLE_DELIMITER_SET }) return emptyList()
+ val codeMatches = LIVE_CODE.findAll(source).toList()
+ // Returns true when either delimiter boundary of this match falls inside a code span. Markdown entirely inside
+ // code, or malformed markdown crossing a code boundary, must not add styling. A code span nested inside outer
+ // emphasis is still allowed, so outer bold text can retain both its bold span and an inner code span.
+ val isBlockedByCodeSpan: (MatchResult) -> Boolean = { match ->
+ codeMatches.any { codeMatch -> match.range.first in codeMatch.range || match.range.last in codeMatch.range }
+ }
+ return buildList {
+ LIVE_BOLD.findAll(source).filterNot(isBlockedByCodeSpan).forEach {
+ add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Bold))
+ }
+ LIVE_ITALIC.findAll(source).filterNot(isBlockedByCodeSpan).forEach {
+ add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Italic))
+ }
+ LIVE_STRIKE.findAll(source).filterNot(isBlockedByCodeSpan).forEach {
+ add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Strikethrough))
+ }
+ codeMatches.forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Code)) }
+ }
+ .sortedWith(compareBy<LiveStyleSpan>({ it.range.first }, { it.range.last }, { it.style.ordinal }))
+}
+
+private val LIVE_STYLE_DELIMITER_SET = setOf('*', '~', '`')
+
+internal data class MentionReplacement(val range: IntRange, val text: String)
+
+internal data class MentionOutputPlan(val replacements: List<MentionReplacement>, val styleSpans: List<LiveStyleSpan>)
+
+/**
+ * Builds presentation edits from the immutable source buffer. Markdown is parsed before friendly-name replacement so
+ * display names containing `*`, `~`, or backticks cannot introduce formatting that was not present in the stored text.
+ */
+internal fun mentionOutputPlan(source: String, candidatesById: Map<String, MentionCandidate>): MentionOutputPlan {
+ val replacements =
+ MENTION_TOKEN_REGEX.findAll(source)
+ .mapNotNull { match ->
+ val candidate = candidatesById[match.groupValues[1]] ?: return@mapNotNull null
+ MentionReplacement(match.range, "@" + candidate.longName.ifEmpty { candidate.shortName })
+ }
+ .toList()
+ val styleSpans = remapStyleSpans(liveInlineMarkdownStyleRanges(source), replacements)
+ return MentionOutputPlan(replacements, styleSpans)
+}
+
+/** Remaps source-buffer style ranges through non-overlapping mention replacements. */
+internal fun remapStyleSpans(spans: List<LiveStyleSpan>, replacements: List<MentionReplacement>): List<LiveStyleSpan> {
+ if (spans.isEmpty() || replacements.isEmpty()) return spans
+ val sortedReplacements = replacements.sortedBy { it.range.first }
+ return spans.mapNotNull { span ->
+ val start = remapBoundary(span.range.first, sortedReplacements, preferReplacementEnd = false)
+ val endExclusive = remapBoundary(span.range.last + 1, sortedReplacements, preferReplacementEnd = true)
+ if (start >= endExclusive) null else span.copy(range = start until endExclusive)
+ }
+}
+
+private fun remapBoundary(
+ sourceOffset: Int,
+ replacements: List<MentionReplacement>,
+ preferReplacementEnd: Boolean,
+): Int {
+ var delta = 0
+ var mappedOffset: Int? = null
+ val iterator = replacements.iterator()
+ while (iterator.hasNext() && mappedOffset == null) {
+ val replacement = iterator.next()
+ val sourceStart = replacement.range.first
+ val sourceEndExclusive = replacement.range.last + 1
+ mappedOffset =
+ when {
+ sourceOffset <= sourceStart -> sourceOffset + delta
+
+ sourceOffset < sourceEndExclusive ->
+ sourceStart + delta + if (preferReplacementEnd) replacement.text.length else 0
+
+ else -> {
+ delta += replacement.text.length - (sourceEndExclusive - sourceStart)
+ null
+ }
+ }
+ }
+ return mappedOffset ?: sourceOffset + delta
}
/**
@@ -545,13 +674,18 @@ private fun Node.matchesMention(query: String): Boolean {
* and the raw markdown delimiters, so the bytes sent are unchanged.
*/
@OptIn(ExperimentalFoundationApi::class)
-private fun mentionOutputTransformation(nodesById: Map<String, Node>) = OutputTransformation {
- for (match in MENTION_TOKEN_REGEX.findAll(toString()).toList().asReversed()) {
- val node = nodesById[match.groupValues[1]] ?: continue
- replace(match.range.first, match.range.last + 1, "@" + node.user.long_name.ifEmpty { node.user.short_name })
+private fun mentionOutputTransformation(candidatesById: Map<String, MentionCandidate>) = OutputTransformation {
+ val source = toString()
+ // Fast path: plain text with no mention tokens or markdown delimiters — skip all scanning.
+ val hasMention = source.indexOf('@') >= 0
+ val needsStyle = hasMention || source.any { it in LIVE_STYLE_DELIMITER_SET }
+ if (!needsStyle) return@OutputTransformation
+
+ val plan = mentionOutputPlan(source, candidatesById)
+ for (replacement in plan.replacements.asReversed()) {
+ replace(replacement.range.first, replacement.range.last + 1, replacement.text)
}
- // Live markdown styling over the (mention-substituted) buffer. Delimiters stay visible; only spans are added.
- for (span in inlineMarkdownStyleRanges(toString())) {
+ for (span in plan.styleSpans) {
val spanStyle =
when (span.style) {
InlineStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
@@ -569,7 +703,8 @@ private fun mentionOutputTransformation(nodesById: Map<String, Node>) = OutputTr
*
* @param isEnabled Whether the input field should be enabled.
* @param textFieldState The [TextFieldState] managing the input's text.
- * @param nodes Known nodes, used for the `@`-mention autocomplete and friendly-name display.
+ * @param mentionCandidates Identity-stable node-id → mention entry. Only changes when a node's id or display name
+ * changes — telemetry-only updates leave this map structurally identical, preventing recomposition churn.
* @param modifier The modifier for this composable.
* @param maxByteSize The maximum allowed size of the message in bytes.
* @param onSendMessage Callback invoked when the send button is pressed or send IME action is triggered.
@@ -580,7 +715,7 @@ private fun MessageInput(
isEnabled: Boolean,
isHomoglyphEncodingEnabled: Boolean,
textFieldState: TextFieldState,
- nodes: List<Node>,
+ mentionCandidates: ImmutableMap<String, MentionCandidate>,
modifier: Modifier = Modifier,
maxByteSize: Int = MESSAGE_CHARACTER_LIMIT_BYTES,
onSendMessage: () -> Unit,
@@ -603,24 +738,23 @@ private fun MessageInput(
val isOverLimit = currentByteLength > maxByteSize
val canSend = !isOverLimit && currentText.isNotEmpty() && isEnabled
- val nodesById = remember(nodes) { nodes.associateBy { it.user.id } }
- val mentionOutput = remember(nodesById) { mentionOutputTransformation(nodesById) }
+ val mentionOutput = remember(mentionCandidates) { mentionOutputTransformation(mentionCandidates) }
val mentionQuery by
remember(textFieldState) {
derivedStateOf { currentMentionQuery(textFieldState.text.toString(), textFieldState.selection) }
}
val suggestions =
- remember(mentionQuery, nodes) {
- val query = mentionQuery?.query ?: return@remember emptyList<Node>()
- nodes.filter { it.matchesMention(query) }.take(MENTION_SUGGESTION_LIMIT)
+ remember(mentionQuery, mentionCandidates) {
+ val query = mentionQuery?.query ?: return@remember emptyList<MentionCandidate>()
+ matchingMentionCandidates(mentionCandidates.values, query)
}
// While the mention popup is open, Enter / send completes the top suggestion instead of sending a raw @query.
val mentionActive = mentionQuery != null && suggestions.isNotEmpty()
- fun insertMention(node: Node) {
+ fun insertMention(candidate: MentionCandidate) {
val q = mentionQuery ?: return
textFieldState.edit {
- val insert = "@${node.user.id} "
+ val insert = "@${candidate.id} "
replace(q.start, q.end, insert)
selection = TextRange(q.start + insert.length)
}
@@ -698,27 +832,29 @@ private fun MessageInput(
}
@Composable
-private fun MentionSuggestions(suggestions: List<Node>, onPick: (Node) -> Unit) {
+private fun MentionSuggestions(suggestions: List<MentionCandidate>, onPick: (MentionCandidate) -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
tonalElevation = 3.dp,
shape = RoundedCornerShape(12.dp),
) {
Column {
- suggestions.forEach { node ->
+ suggestions.forEach { candidate ->
Row(
modifier =
- Modifier.fillMaxWidth().clickable { onPick(node) }.padding(horizontal = 12.dp, vertical = 8.dp),
+ Modifier.fillMaxWidth()
+ .clickable { onPick(candidate) }
+ .padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
- text = node.user.short_name,
+ text = candidate.shortName,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
)
Text(
- text = node.user.long_name,
+ text = candidate.longName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -732,13 +868,14 @@ private fun MentionSuggestions(suggestions: List<Node>, onPick: (Node) -> Unit)
@PreviewLightDark
@Composable
fun MessageInputPreview() {
+ val mentionCandidates = persistentMapOf<String, MentionCandidate>()
AppTheme {
Surface {
Column(modifier = Modifier.padding(8.dp)) {
MessageInput(
isEnabled = true,
isHomoglyphEncodingEnabled = false,
- nodes = emptyList(),
+ mentionCandidates = mentionCandidates,
textFieldState = rememberTextFieldState("Hello"),
onSendMessage = {},
)
@@ -746,7 +883,7 @@ fun MessageInputPreview() {
MessageInput(
isEnabled = false,
isHomoglyphEncodingEnabled = false,
- nodes = emptyList(),
+ mentionCandidates = mentionCandidates,
textFieldState = rememberTextFieldState("Disabled"),
onSendMessage = {},
)
@@ -754,7 +891,7 @@ fun MessageInputPreview() {
MessageInput(
isEnabled = true,
isHomoglyphEncodingEnabled = false,
- nodes = emptyList(),
+ mentionCandidates = mentionCandidates,
textFieldState =
rememberTextFieldState(
"A very long message that might exceed the byte limit " +
@@ -768,7 +905,7 @@ fun MessageInputPreview() {
MessageInput(
isEnabled = true,
isHomoglyphEncodingEnabled = false,
- nodes = emptyList(),
+ mentionCandidates = mentionCandidates,
textFieldState = rememberTextFieldState("こんにちは世界"), // Hello World in Japanese
onSendMessage = {},
maxByteSize = 10,

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index b1b7f79016..3356ce6efd 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -22,6 +22,8 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -34,6 +36,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.currentLocaleCode
import org.meshtastic.core.common.util.ioDispatcher
@@ -108,13 +111,28 @@ class MessageViewModel(
private val _draftMessage = MutableStateFlow(savedStateHandle.get<String>("draftMessage") ?: "")
val draftMessage: StateFlow<String> = _draftMessage.asStateFlow()
+ private var pendingDraftPersistence: Job? = null
+
+ /**
+ * Updates the in-memory draft immediately. The durable [SavedStateHandle] write is debounced by
+ * [DRAFT_PERSISTENCE_DELAY_MS] — rapid edits cancel the prior pending flush, so only the trailing value persists.
+ * On process death within the debounce window the last ≤[DRAFT_PERSISTENCE_DELAY_MS] of typing is not durably
+ * saved; this is a conscious trade-off to avoid per-keystroke jank.
+ */
fun setDraftMessage(text: String) {
_draftMessage.value = text
- savedStateHandle["draftMessage"] = text
+ pendingDraftPersistence?.cancel()
+ pendingDraftPersistence =
+ viewModelScope.launch {
+ delay(DRAFT_PERSISTENCE_DELAY_MS)
+ savedStateHandle["draftMessage"] = text
+ }
}
fun clearDraftMessage() {
_draftMessage.value = ""
+ pendingDraftPersistence?.cancel()
+ pendingDraftPersistence = null
savedStateHandle["draftMessage"] = ""
}
@@ -413,5 +431,6 @@ class MessageViewModel(
companion object {
private const val SEARCH_DEBOUNCE_MS = 300L
private const val MIN_SEARCH_LENGTH = 2
+ private const val DRAFT_PERSISTENCE_DELAY_MS = 300L
}
}

diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt
new file mode 100644
index 0000000000..99a8813d90
--- /dev/null
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageComposerBehaviorTest.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.messaging
+
+import org.meshtastic.core.ui.component.InlineStyle
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class MessageComposerBehaviorTest {
+
+ private val candidates =
+ listOf(
+ MentionCandidate(id = "!a1", longName = "Alpha Node", shortName = "ALP"),
+ MentionCandidate(id = "!b2", longName = "Bravo", shortName = "BRV"),
+ MentionCandidate(id = "!c3", longName = "Charlie", shortName = "CHR"),
+ )
+
+ @Test
+ fun `mention matching is case insensitive across id and display names`() {
+ assertEquals(listOf(candidates[0]), matchingMentionCandidates(candidates, "ALPHA"))
+ assertEquals(listOf(candidates[1]), matchingMentionCandidates(candidates, "brv"))
+ assertEquals(listOf(candidates[2]), matchingMentionCandidates(candidates, "!C3"))
+ }
+
+ @Test
+ fun `mention matching preserves order and stops at the requested limit`() {
+ assertEquals(candidates.take(2), matchingMentionCandidates(candidates, query = "", limit = 2))
+ assertTrue(matchingMentionCandidates(candidates, query = "", limit = 0).isEmpty())
+ }
+
+ @Test
+ fun `live markdown parser returns no spans for plain or incomplete text`() {
+ assertTrue(liveInlineMarkdownStyleRanges("plain text").isEmpty())
+ assertTrue(liveInlineMarkdownStyleRanges("**unfinished").isEmpty())
+ assertTrue(liveInlineMarkdownStyleRanges("`unfinished").isEmpty())
+ }
+
+ @Test
+ fun `live markdown parser identifies supported inline styles without styling delimiters`() {
+ val source = "**bold** *italic* ~~gone~~ `code`"
+ val spans = liveInlineMarkdownStyleRanges(source).map { span -> source.substring(span.range) to span.style }
+
+ assertEquals(
+ listOf(
+ "bold" to InlineStyle.Bold,
+ "italic" to InlineStyle.Italic,
+ "gone" to InlineStyle.Strikethrough,
+ "code" to InlineStyle.Code,
+ ),
+ spans,
+ )
+ }
+
+ @Test
+ fun `live markdown spans are returned in source order`() {
+ val source = "`code` before **bold**"
+ val spans = liveInlineMarkdownStyleRanges(source).map { span -> source.substring(span.range) to span.style }
+
+ assertEquals(listOf("code" to InlineStyle.Code, "bold" to InlineStyle.Bold), spans)
+ }
+
+ @Test
+ fun `live markdown parser ignores style delimiters inside code spans`() {
+ val source = "`**not bold** *not italic* ~~not strike~~`"
+ val spans = liveInlineMarkdownStyleRanges(source).map { span -> source.substring(span.range) to span.style }
+
+ assertEquals(listOf("**not bold** *not italic* ~~not strike~~" to InlineStyle.Code), spans)
+ }
+
+ @Test
+ fun `live markdown parser preserves style surrounding code spans`() {
+ val source = "**before `code` after**"
+ val spans = liveInlineMarkdownStyleRanges(source).map { span -> source.substring(span.range) to span.style }
+
+ assertEquals(listOf("before `code` after" to InlineStyle.Bold, "code" to InlineStyle.Code), spans)
+ }
+
+ @Test
+ fun `mention replacement remaps later markdown span`() {
+ val source = "@!aabbccdd **bold**"
+ val candidate = MentionCandidate(id = "!aabbccdd", longName = "A", shortName = "A")
+ val plan = mentionOutputPlan(source, mapOf(candidate.id to candidate))
+
+ assertEquals(listOf(MentionReplacement(0..9, "@A")), plan.replacements)
+ assertEquals(listOf(LiveStyleSpan(5..8, InlineStyle.Bold)), plan.styleSpans)
+ }
+
+ @Test
+ fun `multiple mention replacements accumulate offsets before later markdown`() {
+ val source = "@!aabbccdd and @!11223344 then `code`"
+ val alpha = MentionCandidate(id = "!aabbccdd", longName = "A", shortName = "A")
+ val bravo = MentionCandidate(id = "!11223344", longName = "Long Bravo", shortName = "B")
+ val plan = mentionOutputPlan(source, mapOf(alpha.id to alpha, bravo.id to bravo))
+
+ assertEquals(listOf(LiveStyleSpan(25..28, InlineStyle.Code)), plan.styleSpans)
+ }
+
+ @Test
+ fun `markdown surrounding mention expands to friendly display name`() {
+ val source = "**@!aabbccdd**"
+ val candidate = MentionCandidate(id = "!aabbccdd", longName = "Alpha", shortName = "A")
+ val plan = mentionOutputPlan(source, mapOf(candidate.id to candidate))
+
+ assertEquals(listOf(LiveStyleSpan(2..7, InlineStyle.Bold)), plan.styleSpans)
+ }
+
+ @Test
+ fun `friendly mention delimiters do not introduce markdown styling`() {
+ val source = "@!aabbccdd plain"
+ val candidate = MentionCandidate(id = "!aabbccdd", longName = "**Alpha**", shortName = "A")
+ val plan = mentionOutputPlan(source, mapOf(candidate.id to candidate))
+
+ assertTrue(plan.styleSpans.isEmpty())
+ }
+
+ @Test
+ fun `bold delimiters are not double counted as italic`() {
+ val spans = liveInlineMarkdownStyleRanges("**bold**")
+
+ assertEquals(listOf(LiveStyleSpan(2..5, InlineStyle.Bold)), spans)
+ }
+}

diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
index 3e6ba9b7ea..351d9d3f13 100644
--- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
@@ -56,6 +56,7 @@ import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
+import kotlin.test.assertNull
class MessageViewModelTest {
@@ -140,6 +141,45 @@ class MessageViewModelTest {
@Test fun testInitialization() = runTest { assertNotNull(viewModel) }
+ @Test
+ fun testDraftPersistenceDebouncesRapidEdits() = runTest {
+ viewModel.setDraftMessage("a")
+ testDispatcher.scheduler.runCurrent()
+ testDispatcher.scheduler.advanceTimeBy(100L)
+
+ viewModel.setDraftMessage("ab")
+ testDispatcher.scheduler.runCurrent()
+ testDispatcher.scheduler.advanceTimeBy(100L)
+
+ viewModel.setDraftMessage("abc")
+ testDispatcher.scheduler.runCurrent()
+
+ assertEquals("abc", viewModel.draftMessage.value)
+ assertNull(savedStateHandle.get<String>("draftMessage"))
+
+ testDispatcher.scheduler.advanceTimeBy(299L)
+ testDispatcher.scheduler.runCurrent()
+ assertNull(savedStateHandle.get<String>("draftMessage"))
+
+ testDispatcher.scheduler.advanceTimeBy(1L)
+ testDispatcher.scheduler.runCurrent()
+ assertEquals("abc", savedStateHandle.get<String>("draftMessage"))
+ }
+
+ @Test
+ fun testClearDraftCancelsPendingPersistenceAndClearsImmediately() = runTest {
+ viewModel.setDraftMessage("pending")
+ testDispatcher.scheduler.runCurrent()
+
+ viewModel.clearDraftMessage()
+ assertEquals("", viewModel.draftMessage.value)
+ assertEquals("", savedStateHandle.get<String>("draftMessage"))
+
+ testDispatcher.scheduler.advanceTimeBy(300L)
+ testDispatcher.scheduler.runCurrent()
+ assertEquals("", savedStateHandle.get<String>("draftMessage"))
+ }
+
@Test
fun testSetTitle() = runTest {
viewModel.title.test {

Served by rngit 1.5.0 - Generated in 0.08s